Skip to content

Conversation

@joshheald
Copy link
Contributor

@joshheald joshheald commented Sep 17, 2025

Description

Some plugins can break the expected JSON for meta_data on Products and Variations. They change the expected array to an object, with string indexes as keys.

{
  "0": {
    "id": 1794,
    "key": "_wpcom_is_markdown",
    "value": "1"
  },
  "1": {
    "id": 46321,
    "key": "_yoast_wpseo_primary_product_cat",
    "value": ""
  },
}

But it should be an array:

[
  {
    "id": 1794,
    "key": "_wpcom_is_markdown",
    "value": "1"
  },
  {
    "id": 46321,
    "key": "_yoast_wpseo_primary_product_cat",
    "value": ""
  },
]

This PR updates our decoding to support this format. Note that we do not re-encode to the incorrect format, so saving custom fields may be broken in this place, but at least they'll be visible.

Steps to reproduce

Ensure you have a product with non-private custom fields (no leading underscore in the key.)

  1. Apply the snippet below to your site to force the incorrect format
  2. Launch the app and open a product with custom fields set
  3. Observe that you can see the custom field data

/**
 * Transform WooCommerce product & variation meta_data
 * from array to object keyed by array index in REST API responses.
 */
function wc_meta_data_array_to_object( $response, $object, $request ) {
    if ( ! ( $response instanceof WP_REST_Response ) ) {
        return $response;
    }

    $data = $response->get_data();

    if ( isset( $data['meta_data'] ) && is_array( $data['meta_data'] ) ) {
        $transformed = [];
        foreach ( $data['meta_data'] as $index => $item ) {
            $transformed[ (string) $index ] = $item;
        }
        $data['meta_data'] = (object) $transformed;
        $response->set_data( $data );
    }

    return $response;
}

add_filter( 'woocommerce_rest_prepare_product_object', 'wc_meta_data_array_to_object', 20, 3 );
add_filter( 'woocommerce_rest_prepare_product_variation_object', 'wc_meta_data_array_to_object', 20, 3 );
add_filter( 'woocommerce_rest_prepare_shop_order_object', 'wc_meta_data_array_to_object', 20, 3 );


Test information

I've checked viewing Custom Fields on Orders and Products, and Interac refunds (which rely on _charge_id.)

Subscription products also use it, but for the moment I've only used unit tests to verify those.


  • I have considered if this change warrants user-facing release notes and have added them to RELEASE-NOTES.txt if necessary.

@joshheald joshheald added this to the 23.3 milestone Sep 17, 2025
@joshheald joshheald added the type: task An internally driven task. label Sep 17, 2025
@wpmobilebot
Copy link
Collaborator

wpmobilebot commented Sep 17, 2025

App Icon📲 You can test the changes from this Pull Request in WooCommerce iOS Prototype by scanning the QR code below to install the corresponding build.

App NameWooCommerce iOS Prototype
Build Numberpr16141-fa134fa
Version23.2
Bundle IDcom.automattic.alpha.woocommerce
Commitfa134fa
Installation URL3h14gbctu82ko
Automatticians: You can use our internal self-serve MC tool to give yourself access to those builds if needed.

@joshheald joshheald requested a review from staskus September 17, 2025 17:48
@joshheald joshheald modified the milestones: 23.3, 23.4 Sep 17, 2025
@joshheald
Copy link
Contributor Author

@staskus not urgent, just when you get to it.

@joshheald joshheald marked this pull request as ready for review September 17, 2025 19:46
@staskus staskus requested a review from Copilot September 18, 2025 06:40
@staskus staskus self-assigned this Sep 18, 2025
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

This PR implements a workaround to handle incorrectly formatted meta_data fields in WooCommerce REST API responses. Some plugins transform the expected array format into an object with string indices as keys, breaking the standard JSON structure for Products, Variations, and Orders.

  • Adds flexible decoding that supports both array and dictionary formats for metadata
  • Updates Product, ProductVariation, and Order models to use the new flexible decoder
  • Maintains backward compatibility while fixing visibility of custom fields

Reviewed Changes

Copilot reviewed 14 out of 14 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
RELEASE-NOTES.txt Documents the workaround for incorrectly formatted meta_data
MetaDataArray+FlexibleDecoding.swift New extension providing flexible decoding for MetaData arrays
ProductMetadataExtractor.swift Updated to handle both array and dictionary metadata formats
Product.swift Modified to use flexible metadata decoding
ProductVariation.swift Modified to use flexible metadata decoding
Order.swift Modified to use flexible metadata decoding
Test files Added comprehensive test coverage for both metadata formats

Tip: Customize your code reviews with copilot-instructions.md. Create the file or learn how to get started.


// Try to decode as object keyed by index strings
if let metaDataDict = try? container.decode([String: MetaData].self, forKey: key) {
return Array(metaDataDict.values)
Copy link

Copilot AI Sep 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using Array(metaDataDict.values) loses the ordering information from the dictionary keys. Since the keys are string indices ('0', '1', '2'), the metadata should be sorted by these keys to preserve the original order. Consider sorting by converting keys to integers: return metaDataDict.sorted { Int($0.key) ?? 0 < Int($1.key) ?? 0 }.map { $0.value }

Suggested change
return Array(metaDataDict.values)
return metaDataDict.sorted { (lhs, rhs) in
(Int(lhs.key) ?? 0) < (Int(rhs.key) ?? 0)
}.map { $0.value }

Copilot uses AI. Check for mistakes.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if it's true, the docs suggest:

When iterated over, values appear in this collection in the same order as they occur in the dictionary’s key-value pairs.

newDict.updateValue(objectValue, forKey: objectKey)
return newDict
private func getKeyValueDictionary(from metadata: [MetaData]) -> AnyDictionary {
metadata.reduce(into: AnyDictionary()) { dict, object in
Copy link

Copilot AI Sep 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The reduce(into:) method is more efficient than the previous reduce() approach since it mutates the accumulator in-place rather than creating new dictionaries on each iteration.

Copilot uses AI. Check for mistakes.
Copy link
Contributor

@staskus staskus left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! Solid work, I appreciate the clear test coverage.

I think it can be clean up slightly by removing the Decodable path from ProductMetadataExtractor.

}

// Fallback to empty array
return []
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe it would be worth logging a case where metadata decoding fails silently? It could help troubleshoot similar issues more easily.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

}

// Try to decode as object keyed by index strings
if let metaDataDict = try? container.decode([String: MetaData].self, forKey: key) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was checking if a plugin could produce [Int: MetaData].self, but it looks unlikely since using numbers as object keys goes against the JSON specs and json_encode() would ensure that the keys are strings.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah. I'm wary of even doing this much workaround for plugins messing with fields that don't belong to them, to be honest... it seems like a short road to insanity trying to pair that with our type safe models.

It'll continue to fail gracefully even if the format is more broken than this.


// Try to decode as object keyed by index strings
if let metaDataDict = try? container.decode([String: MetaData].self, forKey: key) {
return Array(metaDataDict.values)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if it's true, the docs suggest:

When iterated over, values appear in this collection in the same order as they occur in the dictionary’s key-value pairs.

let container = try decoder.singleValueContainer()
self.metadata = try container.decode([DecodableDictionary].self)

// Try to decode as array first (standard format)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was investigating why we need this duplicating logic, and I think the whole init(from decoder: Decoder) throws and Decodable from ProductMetadataExtractor can be removed. I couldn't find any code paths that rely on it. ProductMetadataExtractor is used only through init(metadata:) initializer.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you, I'll check that.

However, I tried a lot last night to have a sane, non-duplicative approach, and failed hard. It was down to the subtle differences of what data source we were trying to map, and how decoded it already was.

Hopefully the cold light of day will let me delete more of this.

Co-authored-by: Povilas Staskus <[email protected]>
@joshheald
Copy link
Contributor Author

I wonder if it's true, the docs suggest

Thanks for the docs. Either way, it doesn't matter – metadata only really makes sense to access by searching for a key anyway.

@joshheald joshheald enabled auto-merge September 18, 2025 10:00
@joshheald joshheald modified the milestones: 23.4, 23.3 Sep 18, 2025
@dangermattic
Copy link
Collaborator

1 Warning
⚠️ This PR is assigned to the milestone 23.3. This milestone is due in less than 2 days.
Please make sure to get it merged by then or assign it to a milestone with a later deadline.

Generated by 🚫 Danger

@joshheald joshheald merged commit 3fd0ab9 into trunk Sep 18, 2025
15 of 16 checks passed
@joshheald joshheald deleted the woomob-1362-decode-productvariation-meta_data-provided-as-an-object-not branch September 18, 2025 10:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type: task An internally driven task.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants